﻿using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Quickly.Spa.Shared;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net.Http.Headers;

namespace Quickly.SpaProxy;

internal sealed class SpaProxyLaunchManager : IDisposable
{
    private readonly SpaDevelopmentServerOptions _options;
    private readonly ILogger<SpaProxyLaunchManager> _logger;
    private readonly object _lock = new object();

    private ConcurrentDictionary<string, Process?> _spaProcesses = new ConcurrentDictionary<string, Process?>();
    private ConcurrentDictionary<string, Task?> _launchTasks = new ConcurrentDictionary<string, Task?>();

    public SpaProxyLaunchManager(
        ILogger<SpaProxyLaunchManager> logger,
        IHostApplicationLifetime appLifetime,
        IOptions<SpaDevelopmentServerOptions> options)
    {
        _options = options.Value;
        _logger = logger;
        appLifetime.ApplicationStopping.Register(() =>
        {
            _logger.LogInformation("application stopping");
            Dispose(true);
        });
        appLifetime.ApplicationStopped.Register(() =>
        {
            _logger.LogInformation("application stopped");
        });
    }

    private static HttpClient CreateHttpClient()
    {
        var httpClient = new HttpClient(new HttpClientHandler()
        {
            // It's ok for us to do this here since this service is only plugged in during development.
            ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
        });
        // We don't care about the returned content type as long as the server is able to answer with 2XX
        httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*", 0.1));
        return httpClient;
    }

    private async Task<bool> ProbeSpaDevelopmentServerUrl(string name, SpaDevelopmentServerOption option, HttpClient httpClient, CancellationToken cancellationToken)
    {
        using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10));
        try
        {
            var response = await httpClient.GetAsync(option.ServerUrl, cancellationTokenSource.Token);
            var running = response.IsSuccessStatusCode;
            return running;
        }
        catch (Exception exception) when (exception is HttpRequestException ||
              exception is TaskCanceledException ||
              exception is OperationCanceledException)
        {
            _logger.LogDebug(exception, "Failed to connect to the SPA Development proxy.");
            return false;
        }
    }

    private async Task StartSpaProcessAndProbeForLiveness(string name, SpaDevelopmentServerOption option, CancellationToken cancellationToken)
    {
        var _spaProcess = LaunchDevelopmentProxy(name, option);
        var sw = Stopwatch.StartNew();
        var livenessProbeSucceeded = false;
        var maxTimeoutReached = false;
        var httpClient = CreateHttpClient();

        while (_spaProcess != null && !_spaProcess.HasExited && !maxTimeoutReached)
        {
            livenessProbeSucceeded = await ProbeSpaDevelopmentServerUrl(name, option, httpClient, cancellationToken);
            if (livenessProbeSucceeded)
            {
                break;
            }

            if (cancellationToken.IsCancellationRequested)
            {
                return;
            }

            maxTimeoutReached = sw.Elapsed >= option.MaxTimeout;
            await Task.Delay(1000, cancellationToken);
        }

        if (_spaProcess == null || _spaProcess.HasExited)
        {
            if (_logger.IsEnabled(LogLevel.Error))
            {
                _logger.LogError($"Couldn't start the SPA development server [{name}] with command '{option.LaunchCommand}'.");
            }
        }
        else if (!livenessProbeSucceeded)
        {
            if (_logger.IsEnabled(LogLevel.Error))
            {
                _logger.LogError($"Unable to connect to the SPA development server [{name}] at '{option.ServerUrl}'.");
            }
        }
        else if (_logger.IsEnabled(LogLevel.Information))
        {
            _logger.LogInformation($"SPA development server [{name}] running at '{option.ServerUrl}'");
        }
    }

    private Process? LaunchDevelopmentProxy(string name, SpaDevelopmentServerOption option)
    {
        try
        {
            var _spaProcess = _spaProcesses.GetOrAdd(name, (key) =>
            {
                // Launch command is going to be something like `npm/yarn <<verb>> <<options>>`
                // We split it into two to separate the tool (command) from the verb and the rest of the arguments.
                var space = option.LaunchCommand.IndexOf(' ');
                var command = option.LaunchCommand[0..space];
                var arguments = option.LaunchCommand[++space..];
                if (OperatingSystem.IsWindows() && !Path.HasExtension(command))
                {
                    // On windows we transform npm/yarn to npm.cmd/yarn.cmd so that the command
                    // can actually be found when we start the process. This is overridable if
                    // necessary by explicitly setting up the extension on the command.
                    command = $"{command}.cmd";
                }

                var info = new ProcessStartInfo(command, arguments)
                {
                    // Linux and Mac OS don't have the concept of launching a terminal process in a new window.
                    // On those cases the process will be launched in the same terminal window and will just print
                    // some output during the start phase of the app.
                    // This is not a problem since users don't need to interact with the proxy other than to stop it
                    // and this is only an optimization to keep the current experience. We can always tell them to
                    // run the proxy manually.
                    CreateNoWindow = false,
                    UseShellExecute = true,
                    WindowStyle = ProcessWindowStyle.Normal,
                    WorkingDirectory = Path.Combine(AppContext.BaseDirectory, option.WorkingDirectory)
                };

                return Process.Start(info);
            });

            if (_spaProcess != null && !_spaProcess.HasExited && !option.KeepRunning)
            {
                if (OperatingSystem.IsWindows())
                {
                    LaunchStopScriptWindows(_spaProcess.Id, option);
                }
                else if (OperatingSystem.IsMacOS())
                {
                    LaunchStopScriptMacOS(_spaProcess.Id, option);
                }
            }

            return _spaProcess;
        }
        catch (Exception exception)
        {
            _logger.LogError(exception, $"Failed to launch the SPA development server '{option.LaunchCommand}'.");

            return null;
        }
    }

    private void LaunchStopScriptWindows(int spaProcessId, SpaDevelopmentServerOption option)
    {
        var stopScript = $@"do{{
  try
  {{
    $processId = Get-Process -PID {Environment.ProcessId} -ErrorAction Stop;
  }}catch
  {{
    $processId = $null;
  }}
  Start-Sleep -Seconds 1;
}}while($processId -ne $null);

try
{{
  taskkill /T /F /PID {spaProcessId};
}}
catch
{{
}}";
        var stopScriptInfo = new ProcessStartInfo(
            "powershell.exe",
            string.Join(" ", "-NoProfile", "-C", stopScript))
        {
            CreateNoWindow = true,
            WorkingDirectory = Path.Combine(AppContext.BaseDirectory, option.WorkingDirectory)
        };

        var stopProcess = Process.Start(stopScriptInfo);
        if (stopProcess == null || stopProcess.HasExited)
        {
            if (_logger.IsEnabled(LogLevel.Warning))
            {
                _logger.LogWarning($"The SPA process shutdown script '{stopProcess?.Id}' failed to start. The SPA proxy might" +
                    $" remain open if the dotnet process is terminated ungracefully. Use the operating system commands to kill" +
                    $" the process tree for {spaProcessId}");
            }
        }
        else
        {
            if (_logger.IsEnabled(LogLevel.Debug))
            {
                _logger.LogDebug($"Watch process '{stopProcess}' started.");
            }
        }
    }

    private void LaunchStopScriptMacOS(int spaProcessId, SpaDevelopmentServerOption option)
    {
        var fileName = Guid.NewGuid().ToString("N") + ".sh";
        var scriptPath = Path.Combine(AppContext.BaseDirectory, fileName);
        var stopScript = @$"function list_child_processes () {{
    local ppid=$1;
    local current_children=$(pgrep -P $ppid);
    local local_child;
    if [ $? -eq 0 ];
    then
        for current_child in $current_children
        do
          local_child=$current_child;
          list_child_processes $local_child;
          echo $local_child;
        done;
    else
      return 0;
    fi;
}}

ps {Environment.ProcessId};
while [ $? -eq 0 ];
do
  sleep 1;
  ps {Environment.ProcessId} > /dev/null;
done;

for child in $(list_child_processes {spaProcessId});
do
  echo killing $child;
  kill -s KILL $child;
done;
rm {scriptPath};
";
        File.WriteAllText(scriptPath, stopScript.ReplaceLineEndings());

        var stopScriptInfo = new ProcessStartInfo("/bin/bash", scriptPath)
        {
            CreateNoWindow = true,
            WorkingDirectory = Path.Combine(AppContext.BaseDirectory, option.WorkingDirectory)
        };

        var stopProcess = Process.Start(stopScriptInfo);
        if (stopProcess == null || stopProcess.HasExited)
        {
            _logger.LogWarning($"The SPA process shutdown script '{stopProcess?.Id}' failed to start. The SPA proxy might" +
                $" remain open if the dotnet process is terminated ungracefully. Use the operating system commands to kill" +
                $" the process tree for {spaProcessId}");
        }
    }

    // =========================================================================== //

    public Task StopAsync()
    {
        Dispose(true);
        return Task.CompletedTask;
    }

    private bool disposedValue;

    private void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                // TODO: 释放托管状态(托管对象)
            }

            try
            {
                if (_spaProcesses.Count > 0)
                {
                    foreach (var _spaProcess in _spaProcesses)
                    {
                        if (_spaProcess.Value != null && !_spaProcess.Value.HasExited)
                        {
                            if (!_spaProcess.Value.CloseMainWindow())
                            {
                                _spaProcess.Value.Kill(entireProcessTree: true);

                                _spaProcesses.TryUpdate(_spaProcess.Key, null, null);
                            }
                        }
                    }
                }
            }
            catch (Exception)
            {
                if (disposing)
                {
                    throw;
                }
            }

            // TODO: 释放未托管的资源(未托管的对象)并重写终结器
            // TODO: 将大型字段设置为 null
            disposedValue = true;
        }
    }

    // // TODO: 仅当“Dispose(bool disposing)”拥有用于释放未托管资源的代码时才替代终结器
    ~SpaProxyLaunchManager()
    {
        // 不要更改此代码。请将清理代码放入“Dispose(bool disposing)”方法中
        Dispose(disposing: false);
    }

    public void Dispose()
    {
        // 不要更改此代码。请将清理代码放入“Dispose(bool disposing)”方法中
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }

    internal async Task<bool> IsSpaProxyRunning(CancellationToken cancellationToken, string? name = null)
    {
        if (string.IsNullOrWhiteSpace(name) || _options.TryGetValue(name, out var option) == false)
        {
            return false;
        }

        var httpClient = CreateHttpClient();

        using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10));

        try
        {
            var response = await httpClient.GetAsync(option.ServerUrl, cancellationTokenSource.Token);
            var running = response.IsSuccessStatusCode;
            return running;
        }
        catch (Exception exception)
        when (exception is HttpRequestException
            || exception is TaskCanceledException
            || exception is OperationCanceledException)
        {
            _logger.LogDebug(exception, $"Failed to connect to the SPA development proxy {option.ServerUrl}");
            return false;
        }
    }

    internal void StartInBackground(CancellationToken cancellationToken, string? name = null)
    {
        lock (_lock)
        {
            if (string.IsNullOrWhiteSpace(name) == false)    // 指定spa名称
            {
                if (_options.TryGetValue(name, out var option)) // 存在指定名称的proxy配置
                {
                    if (_launchTasks.TryGetValue(name, out var _named_lt) == false || _named_lt is null) // launch task不存在或者为null
                    {
                        if (_logger.IsEnabled(LogLevel.Information))
                        {
                            _logger.LogInformation($"No SPA development server [{name}] running at {option.ServerUrl} found.");
                        }

                        var launchTask = UpdateStatus(name, StartSpaProcessAndProbeForLiveness(name, option, cancellationToken));
                        _launchTasks.AddOrUpdate(name, (key) => launchTask, (key, current) => launchTask);
                    }
                }
                else // 不存在指定名称的配置
                {
                    if (_logger.IsEnabled(LogLevel.Information))
                    {
                        _logger.LogInformation($"No SPA development server [{name}] proxy configration found.");
                    }
                }
            }
            else // 未指定名称
            {
                foreach (var server in _options)
                {
                    if (_launchTasks.TryGetValue(server.Key, out var _lt) && _lt != null)
                    {
                        continue;
                    }

                    if (_logger.IsEnabled(LogLevel.Information))
                    {
                        _logger.LogInformation($"No SPA development server [{server.Key}] running at {server.Value.ServerUrl} found.");
                    }

                    var launchTask = UpdateStatus(server.Key, StartSpaProcessAndProbeForLiveness(server.Key, server.Value, cancellationToken));
                    _launchTasks.AddOrUpdate(server.Key, (key) => launchTask, (key, current) => launchTask);
                }
            }
        }

        async Task UpdateStatus(string name, Task launchTask)
        {
            try
            {
                await launchTask;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "There was an error trying to launch the SPA proxy.");
            }
            finally
            {
                lock (_lock)
                {
                    _launchTasks[name] = null;
                }
            }
        }
    }
}
